<?PHP
#
#   FILE:  Datastore.php
#
#   Part of the ScoutLib application support library
#   Copyright 2019-2021 Edward Almasy and Internet Scout Research Group
#   http://scout.wisc.edu
#
# @scout:phpstan

namespace ScoutLib;

use Exception;
use InvalidArgumentException;

/**
 * Class for storing/retrieving a set of prescribed values in a database table.
 * Constructor is protected, so implementing classes must either override with a
 * public constructor, or provide another way to instantiate.
 *
 * The $Fields parameter to the constructor (which defines the values to be
 * stored) is an array of associative arrays, with the outer index being field
 * names, and the inner index consisting of the following value:
 *      REQUIRED
 *      "Default" - Default value for field.
 *      "Description" - Printable plain text description of field.
 *      "Type" - The field type (TYPE_ constant).
 *      OPTIONAL
 *      "MaxVal" - Maximum value for field.  (TYPE_INT and TYPE_FLOAT only)
 *      "MinVal" - Maximum value for field.  (TYPE_INT and TYPE_FLOAT only)
 *      "ValidateFunction" - Function to call to validate values, should
 *          have signature:
 *              validateFunct(string $FieldName, $Value): string
 *          and return NULL if the value is valid, or a string with a message
 *          about why the value is invalid.  If this parameter is supplied,
 *          "MaxVal", "MinVal", and "ValidValues" will be ignored.
 *      "ValidValues" - Array of valid values for field.
 */
abstract class Datastore
{
    # ---- PUBLIC INTERFACE --------------------------------------------------

    # field types
    const TYPE_ARRAY = "TYPE_ARRAY";
    const TYPE_BOOL = "TYPE_BOOL";
    const TYPE_DATETIME = "TYPE_DATETIME";
    const TYPE_EMAIL = "TYPE_EMAIL";            # use get/setString() to get/set
    const TYPE_FLOAT = "TYPE_FLOAT";
    const TYPE_INT = "TYPE_INT";
    const TYPE_IPADDRESS = "TYPE_IPADDRESS";    # use get/setString() to get/set
    const TYPE_STRING = "TYPE_STRING";
    const TYPE_URL = "TYPE_URL";                # use get/setString() to get/set

    /**
     * Get array field value.
     * @param string $FieldName Name of field.
     * @return array Current value for field.
     */
    public function getArray(string $FieldName): array
    {
        $this->checkFieldNameAndType($FieldName, [ self::TYPE_ARRAY ]);
        return $this->Values[$FieldName];
    }

    /**
     * Set array field value.
     * @param string $FieldName Name of field.
     * @param array $Value New value for field.
     */
    public function setArray(string $FieldName, array $Value)
    {
        $this->checkFieldNameAndType($FieldName, [ self::TYPE_ARRAY ]);
        self::checkValue($this->Fields[$FieldName], $FieldName, $Value);
        $this->updateValueInDatabase($FieldName, serialize($Value));
        $this->Values[$FieldName] = $Value;
    }

    /**
     * Get boolean field value.
     * @param string $FieldName Name of field.
     * @return bool Current value for field.
     */
    public function getBool(string $FieldName): bool
    {
        $this->checkFieldNameAndType($FieldName, [ self::TYPE_BOOL ]);
        $this->checkThatValueIsAvailable($FieldName);
        return $this->Values[$FieldName];
    }

    /**
     * Set boolean field value.
     * @param string $FieldName Name of field.
     * @param bool $Value New value for field.
     */
    public function setBool(string $FieldName, bool $Value)
    {
        $this->checkFieldNameAndType($FieldName, [ self::TYPE_BOOL ]);
        self::checkValue($this->Fields[$FieldName], $FieldName, $Value);
        $this->updateValueInDatabase($FieldName, $Value ? "1" : "0");
        $this->Values[$FieldName] = $Value;
    }

    /**
     * Get date/time field value.
     * @param string $FieldName Name of field.
     * @return int Current value for field, as a Unix timestamp.
     */
    public function getDatetime(string $FieldName): int
    {
        $this->checkFieldNameAndType($FieldName, [ self::TYPE_DATETIME ]);
        $this->checkThatValueIsAvailable($FieldName);
        return strtotime($this->Values[$FieldName]);
    }

    /**
     * Set date/time field value.
     * @param string $FieldName Name of field.
     * @param int|string $Value New value for field, as a Unix timestamp or in
     *      any format parseable by strtotime().
     */
    public function setDatetime(string $FieldName, $Value)
    {
        $this->checkFieldNameAndType($FieldName, [ self::TYPE_DATETIME ]);
        self::checkValue($this->Fields[$FieldName], $FieldName, $Value);
        $this->Values[$FieldName] = $this->convertDateToDatabaseFormat($Value);
        $this->updateValueInDatabase($FieldName, $this->Values[$FieldName]);
    }

    /**
     * Get float field value.
     * @param string $FieldName Name of field.
     * @return float Current value for field.
     */
    public function getFloat(string $FieldName): float
    {
        $this->checkFieldNameAndType($FieldName, [ self::TYPE_FLOAT ]);
        $this->checkThatValueIsAvailable($FieldName);
        return $this->Values[$FieldName];
    }

    /**
     * Set float field value.
     * @param string $FieldName Name of field.
     * @param float $Value New value for field.
     */
    public function setFloat(string $FieldName, float $Value)
    {
        $this->checkFieldNameAndType($FieldName, [ self::TYPE_FLOAT ]);
        self::checkValue($this->Fields[$FieldName], $FieldName, $Value);
        $this->updateValueInDatabase($FieldName, (string)$Value);
        $this->Values[$FieldName] = $Value;
    }

    /**
     * Get integer field value.
     * @param string $FieldName Name of field.
     * @return int Current value for field.
     */
    public function getInt(string $FieldName): int
    {
        $this->checkFieldNameAndType($FieldName, [ self::TYPE_INT ]);
        $this->checkThatValueIsAvailable($FieldName);
        return $this->Values[$FieldName];
    }

    /**
     * Set integer field value.
     * @param string $FieldName Name of field.
     * @param int $Value New value for field.
     */
    public function setInt(string $FieldName, int $Value)
    {
        $this->checkFieldNameAndType($FieldName, [ self::TYPE_INT ]);
        self::checkValue($this->Fields[$FieldName], $FieldName, $Value);
        $this->updateValueInDatabase($FieldName, (string)$Value);
        $this->Values[$FieldName] = $Value;
    }

    /**
     * Get string-value (string, email, IP address, or URL) field value.
     * @param string $FieldName Name of field.
     * @return string Current value for field.
     */
    public function getString(string $FieldName): string
    {
        $this->checkFieldNameAndType($FieldName, self::$StringBasedTypes);
        $this->checkThatValueIsAvailable($FieldName);
        return $this->Values[$FieldName];
    }

    /**
     * Set string-value (string, email, IP address, or URL) field value.
     * @param string $FieldName Name of field.
     * @param string $Value New value for field.
     */
    public function setString(string $FieldName, string $Value)
    {
        $this->checkFieldNameAndType($FieldName, self::$StringBasedTypes);
        self::checkValue($this->Fields[$FieldName], $FieldName, $Value);
        $this->updateValueInDatabase($FieldName, $Value);
        $this->Values[$FieldName] = $Value;
    }

    /**
     * Check whether a field has a value set.  (This is regardless of
     * whether there is default value for the field.)
     * @param string $FieldName Name of field.
     * @return bool TRUE if value is set, otherwise FALSE.
     * @see Datastore::unset()
     */
    public function isSet(string $FieldName): bool
    {
        $ColumnName = Database::normalizeToColumnName($FieldName);
        return ($this->RawValues[$ColumnName] === null) ? false : true;
    }

    /**
     * Unset a field, so that it has no value set.  This is independent of
     * whether there is a default value for the field;  if a field is unset
     * and it has a default, the value method will return the default.
     * @param string $FieldName Name of field.
     * @see Datastore::isSet()
     */
    public function unset(string $FieldName)
    {
        $this->updateValueInDatabase($FieldName, null);
    }

    /**
     * Get field type.
     * @return string Field type (TYPE_ constant).
     */
    public function getFieldType(string $FieldName): string
    {
        if (!isset($this->Fields[$FieldName])) {
            throw new InvalidArgumentException("Unknown field name \"".$FieldName.".\".");
        }
        return $this->Fields[$FieldName]["Type"];
    }

    /**
     * Check whether field exists with specified name.
     * @param string $FieldName Name of field.
     * @return bool TRUE if field exists, otherwise FALSE.
     */
    public function fieldExists(string $FieldName): bool
    {
        return isset($this->Fields[$FieldName]) ? true : false;
    }


    # ---- PRIVATE INTERFACE -------------------------------------------------

    protected $DB;
    protected $Fields;
    protected $DbTableName;
    protected $RawValues;
    protected $Values;

    protected static $StringBasedTypes = [
            self::TYPE_EMAIL,
            self::TYPE_IPADDRESS,
            self::TYPE_STRING,
            self::TYPE_URL,
            ];

    /**
     * Object constructor.
     * @param array $Fields List of fields in which to store values, with field
     *      name for the index, and an associative array with "Type", "Default",
     *      and "Description" entries for each field.
     * @param string $DbTableName Name of database table in which to save values.
     */
    protected function __construct(array $Fields, string $DbTableName)
    {
        $this->DB = new Database();
        $this->Fields = $Fields;
        $this->DbTableName = $DbTableName;
        $this->checkDatabaseTable();
        $this->loadFieldsFromDatabase();
    }

    /**
     * Check that supplied fields table is all valid.
     * @param array $Fields System configuration fields list, with "Type",
     *      "Default", and "Description" entries for each field.
     * @throws InvalidArgumentException If no type is specified for a field.
     * @throws InvalidArgumentException If an invalid type is specified for a field.
     * @throws InvalidArgumentException If no default is specified for a field.
     * @throws InvalidArgumentException If no description is specified for a field.
     */
    protected static function checkFieldsList(array $Fields)
    {
        foreach ($Fields as $FieldName => $FieldInfo) {
            # check that type is specified
            if (!isset($FieldInfo["Type"])) {
                throw new InvalidArgumentException("No type specified for field \""
                    .$FieldName."\".");
            # check that specified type is valid
            } elseif (StdLib::getConstantName(__CLASS__, $FieldInfo["Type"], "TYPE_")
                    === null) {
                throw new InvalidArgumentException("Invalid type specified for field \""
                        .$FieldName."\".");
            }

            # check that valid value list has entries if specified
            if (isset($FieldInfo["ValidValues"])) {
                if (!is_array($FieldInfo["ValidValues"])) {
                    throw new InvalidArgumentException("Valid values list supplied"
                            ." that is not an array.");
                }
                if (!count($FieldInfo["ValidValues"])) {
                    throw new InvalidArgumentException("Valid values list supplied"
                            ." with no entries.");
                }
            }

            # check that default is specified
            if (!array_key_exists("Default", $FieldInfo)) {
                throw new InvalidArgumentException("No default specified for field \""
                        .$FieldName."\".");
            } else {
                # check that default value is correct type
                self::checkFieldDefaultType(
                    $FieldName,
                    $FieldInfo["Default"],
                    $FieldInfo["Type"]
                );
                # check that default value is valid
                if ($FieldInfo["Default"] !== null) {
                    self::checkValue($FieldInfo, $FieldName, $FieldInfo["Default"]);
                }
            }

            # check that description is specified
            if (!isset($FieldInfo["Description"])) {
                throw new InvalidArgumentException("No description specified for"
                        ." field \"".$FieldName."\".");
            }

            # check that minimum or maximum are not specified for non-numeric field
            if (!self::isNumericFieldType($FieldInfo["Type"])) {
                if (isset($FieldInfo["MinVal"])) {
                    throw new InvalidArgumentException("Minimum value specified"
                            ." for non-numeric field \"".$FieldName."\".");
                }
                if (isset($FieldInfo["MaxVal"])) {
                    throw new InvalidArgumentException("Maximum value specified"
                            ." for non-numeric field \"".$FieldName."\".");
                }
            }
        }
    }

    /**
     * Check that field default value has a valid type.
     * @param string $FieldName Name of field.
     * @param mixed $Default Default value.
     * @param string $Type Field type.
     */
    protected static function checkFieldDefaultType(
        string $FieldName,
        $Default,
        string $Type
    ) {
        if ($Default !== null) {
            switch ($Type) {
                case self::TYPE_ARRAY:
                    if (!is_array($Default)) {
                        throw new InvalidArgumentException(
                            "Default value for field \"".$FieldName
                                    ."\" of type ARRAY is not an array."
                        );
                    }
                    break;

                case self::TYPE_BOOL:
                    if (!is_bool($Default)) {
                        throw new InvalidArgumentException(
                            "Default value for field \"".$FieldName
                                    ."\" of type BOOL is not true or false."
                        );
                    }
                    break;

                case self::TYPE_DATETIME:
                    if (!is_numeric($Default) && (strtotime($Default) === false)) {
                        throw new InvalidArgumentException(
                            "Default value for field \"".$FieldName
                                    ."\" of type DATETIME is not a Unix timestamp"
                                    ." or a parseable date."
                        );
                    }
                    break;

                case self::TYPE_FLOAT:
                case self::TYPE_INT:
                    if (!is_numeric($Default)) {
                        $TypeName = ($Type == self::TYPE_INT) ? "INT" : "FLOAT";
                        throw new InvalidArgumentException(
                            "Default value for field \"".$FieldName
                                    ."\" of type ".$TypeName." is not a number."
                        );
                    }
                    break;

                case self::TYPE_STRING:
                    if (!is_string($Default)) {
                        throw new InvalidArgumentException(
                            "Default value for field \"".$FieldName
                                    ."\" of type STRING is not a string."
                        );
                    }
                    break;
            }
        }
    }

    /**
     * Check table in database and add any columns that are missing.
     * @throws InvalidArgumentException If database table does not exist.
     */
    protected function checkDatabaseTable()
    {
        if (!$this->DB->tableExists($this->DbTableName)) {
            throw new InvalidArgumentException("Database table \""
                    .$this->DbTableName."\" does not exist.");
        }

        $ColumnTypes = [
                self::TYPE_ARRAY => "BLOB",
                self::TYPE_BOOL => "INT",
                self::TYPE_DATETIME => "DATETIME",
                self::TYPE_EMAIL => "TEXT",
                self::TYPE_FLOAT => "FLOAT",
                self::TYPE_INT => "INT",
                self::TYPE_IPADDRESS => "TEXT",
                self::TYPE_STRING => "TEXT",
                self::TYPE_URL => "TEXT",
                ];
        foreach ($this->Fields as $FieldName => $FieldInfo) {
            $ColumnName = Database::normalizeToColumnName($FieldName);
            if (!$this->DB->fieldExists($this->DbTableName, $ColumnName)) {
                $Query = "ALTER TABLE ".$this->DbTableName." ADD COLUMN `"
                        .$ColumnName."` ".$ColumnTypes[$FieldInfo["Type"]];
                $this->DB->query($Query);
            }
        }
    }

    /**
     * Check to make sure that value is available for field.
     * @param string $FieldName Name of field.
     * @throws Exception If no value is available.
     */
    protected function checkThatValueIsAvailable(string $FieldName)
    {
        if ($this->Values[$FieldName] === null) {
            throw new Exception("No value is available for field \"".$FieldName."\".");
        }
    }

    /**
     * Convert a date/time value to format suitable for storing in a DATETIME
     * column in the database.
     * @param int|string $Date Date value to convert (Unix timestamp or in any
     *      format parseable by strtotime()).
     * @return string Date normalized for database storage.
     */
    protected function convertDateToDatabaseFormat($Date): string
    {
        if (!is_numeric($Date)) {
            $Result = strtotime($Date);
            if ($Result === false) {
                throw new InvalidArgumentException("Unrecognized date format"
                        ." provided (\"".$Date."\").");
            }
            $Date = $Result;
        }
        return date(StdLib::SQL_DATE_FORMAT, (int)$Date);
    }

    /**
     * Load current fields values from database.
     */
    protected function loadFieldsFromDatabase()
    {
        # retrieve current fields from database
        $this->DB->query("SELECT * FROM `".$this->DbTableName."`");
        $RowsSelected = $this->DB->numRowsSelected();
        if ($RowsSelected == 0) {
            throw new Exception("No rows unexpectedly found in datastore"
                    ." table ".$this->DbTableName.".");
        } elseif ($RowsSelected > 1) {
            throw new Exception("Multiple rows unexpectedly found in"
                    ." datastore table ".$this->DbTableName.".");
        }
        $this->RawValues = $this->DB->fetchRow();

        # unpack fields retrieved from database
        foreach ($this->Fields as $FieldName => $FieldInfo) {
            $ColumnName = Database::normalizeToColumnName($FieldName);
            if ($this->RawValues[$ColumnName] === null) {
                $this->Values[$FieldName] = $FieldInfo["Default"];
            } else {
                switch ($FieldInfo["Type"]) {
                    case self::TYPE_ARRAY:
                        $this->Values[$FieldName] =
                                    unserialize($this->RawValues[$ColumnName]);
                        break;

                    case self::TYPE_BOOL:
                        $this->Values[$FieldName] =
                                $this->RawValues[$ColumnName] ? true : false;
                        break;

                    default:
                        $this->Values[$FieldName] = $this->RawValues[$ColumnName];
                        break;
                }
            }
        }
    }

    /**
     * Check if field name is valid and of one of the specified types,
     * throwing an exception if not.
     * @param string $Name Name of field.
     * @param array $Types Possible received field types.
     * @throws InvalidArgumentException If field name is invalid.
     * @throws InvalidArgumentException If field type is invalid.
     */
    protected function checkFieldNameAndType(string $Name, array $Types)
    {
        if (!isset($this->Fields[$Name])) {
            throw new InvalidArgumentException("Invalid field name \""
                    .$Name."\".");
        }
        if (!in_array($this->Fields[$Name]["Type"], $Types)) {
            $ReceivedTypes = [];
            foreach ($Types as $Type) {
                $ReceivedTypes[] = StdLib::getConstantName(
                    __CLASS__,
                    $Type,
                    "TYPE_"
                );
            }
            $Received = join("|", $ReceivedTypes);
            $Expected = StdLib::getConstantName(
                __CLASS__,
                $this->Fields[$Name]["Type"],
                "TYPE_"
            );
            throw new InvalidArgumentException("Attempt to access field \""
                    .$Name."\" of type ".$Expected
                    ." by calling method for type ".$Received);
        }
    }

    /**
     * Check validity of value for specified field.
     * @param array $FieldInfo Settings for field.
     * @param string $FieldName Name of field.
     * @param mixed $Value Value to check.
     */
    protected static function checkValue(array $FieldInfo, string $FieldName, $Value)
    {
        if (isset($FieldInfo["ValidateFunction"])) {
            self::checkValueUsingValidationFunction(
                $FieldInfo["ValidateFunction"],
                $FieldName,
                $Value
            );
        } else {
            self::checkValueUsingTypeConstraints(
                $FieldInfo,
                $FieldName,
                $Value
            );
        }
    }

    /**
     * Check validity of value using validation function.
     * @param callable $Function Validation function.
     * @param string $FieldName Name of field.
     * @param mixed $Value Value to check.
     * @throws InvalidArgumentException If value is found to be invalid.
     */
    protected static function checkValueUsingValidationFunction(
        callable $Function,
        string $FieldName,
        $Value
    ) {
        $Params = [ $Value, $FieldName ];
        $ErrMsg = call_user_func($Function, $Params);
        if ($ErrMsg === false) {
            throw new Exception("Calling validation function for"
                    ." field \"".$FieldName."\" failed.");
        }
        if ($ErrMsg !== null) {
            throw new InvalidArgumentException("Invalid value (\""
                    .$Value."\") for field \"".$FieldName."\": ".$ErrMsg);
        }
    }

    /**
     * Check validity of value based on field type constraints.
     * @param array $FieldInfo Settings for field.
     * @param string $FieldName Name of field.
     * @param mixed $Value Value to check.
     * @throws InvalidArgumentException If value is found to be invalid.
     */
    protected static function checkValueUsingTypeConstraints(
        array $FieldInfo,
        string $FieldName,
        $Value
    ) {
        $Filters = [
                self::TYPE_EMAIL => FILTER_VALIDATE_EMAIL,
                self::TYPE_FLOAT => FILTER_VALIDATE_FLOAT,
                self::TYPE_INT => FILTER_VALIDATE_INT,
                self::TYPE_IPADDRESS => FILTER_VALIDATE_IP,
                self::TYPE_URL => FILTER_VALIDATE_URL,
                ];
        if (isset($Filters[$FieldInfo["Type"]])) {
            $Result = filter_var($Value, $Filters[$FieldInfo["Type"]]);
            if ($Result === false) {
                throw new InvalidArgumentException("Invalid value (\""
                        .$Value."\") for field \"".$FieldName."\".");
            }
        }

        if (isset($FieldInfo["ValidValues"])
                && !in_array($Value, $FieldInfo["ValidValues"])) {
            throw new InvalidArgumentException("Value (\"".$Value
                    ."\") for field \"".$FieldName."\" is not in list"
                    ." of valid values.");
        }

        switch ($FieldInfo["Type"]) {
            case self::TYPE_DATETIME:
                if (!is_numeric($Value) && (strtotime($Value) === false)) {
                    throw new InvalidArgumentException("Value (\"".$Value
                            ."\") for field \"".$FieldName."\" was not in"
                            ." a recognized date/time format.");
                }
                break;

            case self::TYPE_FLOAT:
            case self::TYPE_INT:
                if (isset($FieldInfo["MinVal"]) && ($Value < $FieldInfo["MinVal"])) {
                    throw new InvalidArgumentException("Value (\"".$Value
                            ."\") for field \"".$FieldName."\" is below"
                            ." minimum value (\"".$FieldInfo["MinVal"]."\").");
                }
                if (isset($FieldInfo["MaxVal"]) && ($Value > $FieldInfo["MaxVal"])) {
                    throw new InvalidArgumentException("Value (\"".$Value
                            ."\") for field \"".$FieldName."\" is above"
                            ." maximum value (\"".$FieldInfo["MaxVal"]."\").");
                }
                break;
        }
    }

    /**
     * Report whether specified field type is numeric.
     * @param string $Type Type to check.
     * @return bool TRUE if type is numeric, otherwise FALSE.
     */
    protected static function isNumericFieldType(string $Type): bool
    {
        $NumericFieldTypes = [
                self::TYPE_INT,
                self::TYPE_FLOAT,
                ];
        return in_array($Type, $NumericFieldTypes);
    }

    /**
     * Update field value in database.
     * @param string $FieldName Name of field.
     * @param string|null $Value Value to save to database (in string form).
     */
    protected function updateValueInDatabase(string $FieldName, $Value)
    {
        $ColumnName = Database::normalizeToColumnName($FieldName);
        $this->RawValues[$ColumnName] = $Value;
        $QueryValue =  ($Value === null) ? "NULL"
                : "'".$this->DB->escapeString($Value)."'";
        $this->DB->query("UPDATE `".$this->DbTableName
                ."` SET `".$ColumnName."` = ".$QueryValue);
    }
}
